/* 
 * Copyright 2010 Joonas Lehtinen
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 * 
 *     http://www.apache.org/licenses/LICENSE-2.0
 *     
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package fi.jole.xmlui;

import java.beans.BeanInfo;
import java.beans.IntrospectionException;
import java.beans.Introspector;
import java.beans.PropertyDescriptor;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Stack;

import javax.xml.namespace.QName;
import javax.xml.stream.FactoryConfigurationError;
import javax.xml.stream.XMLInputFactory;
import javax.xml.stream.XMLOutputFactory;
import javax.xml.stream.XMLStreamConstants;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.XMLStreamReader;
import javax.xml.stream.XMLStreamWriter;

import com.vaadin.data.Property;
import com.vaadin.terminal.Sizeable;
import com.vaadin.ui.AbsoluteLayout;
import com.vaadin.ui.AbstractOrderedLayout;
import com.vaadin.ui.Accordion;
import com.vaadin.ui.Alignment;
import com.vaadin.ui.AlignmentUtils;
import com.vaadin.ui.Component;
import com.vaadin.ui.ComponentContainer;
import com.vaadin.ui.CssLayout;
import com.vaadin.ui.Form;
import com.vaadin.ui.GridLayout;
import com.vaadin.ui.Layout;
import com.vaadin.ui.Panel;
import com.vaadin.ui.TabSheet;
import com.vaadin.ui.Window;
import com.vaadin.ui.Layout.AlignmentHandler;
import com.vaadin.ui.Layout.MarginHandler;
import com.vaadin.ui.Layout.MarginInfo;

/**
 * Tool for initializing UI from XML and serializing to XML.
 * 
 * <h2>How to instantiate an UI from XML</h2>
 * 
 * <code>
 * XMLUI ui = new XMLUI();
 * ui.readFrom(myInputStream);
 * mainWindow.addComponent(ui.getRoot());
 * Button myButton = (Button) ui.getById("myButton");
 * myButton.addListener(...)
 * </code>
 * 
 * <h2>How to serialize an UI to XML</h2>
 * 
 * <code>
 * XMLUI ui = new XMLUI();
 * ui.setRoot(myFancyUI);
 * ui.setId(myButton,"button to remember");
 * ui.writeTo(outputStreamToSomewhere);
 * </code>
 * 
 * @author Joonas Lehtinen
 * 
 */
public class XMLUI {

    Component root;
    HashMap<String, Component> idComponentMap = new HashMap<String, Component>();
    HashMap<Component, String> componentIdMap = new HashMap<Component, String>();
    private static String DEFAULT_NAMESPACE = "com.vaadin.ui";
    private static String NULL_ENCODING = "null";

    /** Sets the root component of the user interface component tree */
    public Component getRoot() {
        return root;
    }

    /** Sets the root component of the user interface component tree */
    public void setRoot(Component newRoot) {
        root = newRoot;
    }

    /**
     * Get a component in the UI by its assigned id.
     * 
     * @param id
     *            Id assigned to the component with id attribute in XML.
     */
    public Component getById(String id) {
        return idComponentMap.get(id);
    }

    /**
     * Get an id for a component.
     * 
     * @return Id assigned to the component in the XML with id attribute or null
     *         if no id is assigned.
     */
    public String getId(Component component) {
        return componentIdMap.get(component);
    }

    /**
     * Sets an id for a component. By setting an id, the id attiribute will be
     * given for the tag corresponding to the component when UI is serialized to
     * XML.
     * 
     * @param component
     *            that will be assigned a new id
     * @param id
     *            string to be assigned for the component.
     */
    public void setId(Component component, String id) {
        String prevId = getId(component);
        if (prevId != null) {
            idComponentMap.remove(prevId);
        }
        idComponentMap.put(id, component);
        componentIdMap.put(component, id);
    }

    /**
     * Writes the component tree to XML.
     * 
     * The XML will be written
     * 
     * @throws IOException
     * @throws XMLUIException
     * @param out
     *            stream to write the XML to.
     */
    public void writeTo(OutputStream out) throws XMLUIException {
        if (getRoot() == null) {
            throw new XMLUIException(
                    "Root component must be set before writing XML.");
        }
        try {
            XMLStreamWriter writer = XMLOutputFactory.newInstance()
                    .createXMLStreamWriter(out);
            writer.writeStartDocument();
            writeComponent(getRoot(), writer, true);
            writer.writeEndDocument();
        } catch (XMLStreamException xmlException) {
            throw new XMLUIException(xmlException);
        } catch (FactoryConfigurationError factoryException) {
            throw new XMLUIException(factoryException);
        }
    }

    private void writeComponent(Component component, XMLStreamWriter writer,
            boolean includeNamespace) throws XMLStreamException, XMLUIException {
        if (component == null) {
            throw new NullPointerException(
                    "Null components can not be written.");
        }
        String ns = component.getClass().getPackage().getName();
        String tag = component.getClass().getSimpleName();
        if (DEFAULT_NAMESPACE.equals(ns)) {
            writer.writeStartElement(tag);
        } else {
            writer.writeStartElement(ns, tag);
        }
        if (includeNamespace) {
            writer.writeDefaultNamespace(DEFAULT_NAMESPACE);
        }
        writeComponentAttributes(component, writer);
        if (component instanceof Panel) {
            ComponentContainer cc = ((Panel) component).getContent();
            writeComponent(cc, writer, false);
        } else if (component instanceof Form) {
            ComponentContainer cc = ((Form) component).getLayout();
            writeComponent(cc, writer, false);
        } else if (component instanceof ComponentContainer) {
            for (Iterator<Component> i = ((ComponentContainer) component)
                    .getComponentIterator(); i.hasNext();) {
                Component child = i.next();
                writer.writeStartElement("c");
                writeChildComponentAttributes(component, child, writer);
                writeComponent(child, writer, false);
                writer.writeEndElement();
            }
        }
        if (component instanceof Window) {
            for (Iterator<Window> i = ((Window) component).getChildWindows()
                    .iterator(); i.hasNext();) {
                Window child = i.next();
                writeChildComponentAttributes(component, child, writer);
                writeComponent(child, writer, false);
            }
        }
        if (component instanceof Property) {
            Property p = (Property) component;
            if (isTypeSupported(p.getType())) {
                writer.writeCharacters(p.toString());
            }
        }
        writer.writeEndElement();
    }

    private void writeChildComponentAttributes(Component component,
            Component child, XMLStreamWriter writer) throws XMLStreamException {
        if (component instanceof AlignmentHandler) {
            Alignment align = ((AlignmentHandler) component)
                    .getComponentAlignment(child);
            if (!align.isLeft() || !align.isTop()) {
                writer.writeAttribute("alignment", align
                        .getHorizontalAlignment()
                        + " " + align.getVerticalAlignment());
            }
        }
        if (component instanceof TabSheet) {
            TabSheet.Tab tab = ((TabSheet) component).getTab(child);
            if (tab.getCaption() != null) {
                writer.writeAttribute("caption", tab.getCaption());
            }
            if (tab.getDescription() != null) {
                writer.writeAttribute("description", tab.getDescription());
            }
        } else if (component instanceof AbstractOrderedLayout) {
            float expandRatio = ((AbstractOrderedLayout) component)
                    .getExpandRatio(child);
            if (expandRatio > 0.0) {
                writer.writeAttribute("expandRatio", "" + expandRatio);
            }
        } else if (component instanceof GridLayout) {
            // TODO Implement GridLayout support
            throw new UnsupportedOperationException("GridLayout");
        } else if (component instanceof AbsoluteLayout) {
            // TODO Implement AbsoluteLayout support
            throw new UnsupportedOperationException("AbsoluteLayout");
        } else if (component instanceof Accordion) {
            // TODO Implement Accordion support
            throw new UnsupportedOperationException("Accordion");
        } else if (component instanceof CssLayout) {
            // TODO Implement CssLayout support
            throw new UnsupportedOperationException("CssLayout");
        }
    }

    private void writeComponentAttributes(Component component,
            XMLStreamWriter writer) throws XMLStreamException, XMLUIException {

        if (getId(component) != null) {
            writer.writeAttribute("id", getId(component));
        }

        Map<String, String> properties = getNonDefaultProperties(component);
        for (String name : properties.keySet()) {
            writer.writeAttribute(name, properties.get(name));
        }
    }

    private Map<String, String> getNonDefaultProperties(Component component)
            throws XMLUIException {
        Component copy;
        try {
            copy = component.getClass().newInstance();
        } catch (InstantiationException e) {
            throw new XMLUIException("All components must be instantiable", e);
        } catch (IllegalAccessException e) {
            throw new XMLUIException("All components must be instantiable", e);
        }
        Map<String, String> properties = new HashMap<String, String>();

        try {
            BeanInfo bi = Introspector.getBeanInfo(component.getClass());
            PropertyDescriptor pds[] = bi.getPropertyDescriptors();
            for (int i = 0; i < pds.length; i++) {
                String name = pds[i].getName();
                Method readMethod = pds[i].getReadMethod();
                Class type = pds[i].getPropertyType();
                if (readMethod != null && isTypeSupported(type)) {
                    Object value = readMethod.invoke(component);
                    Object defaultValue = readMethod.invoke(copy);
                    if (value != defaultValue
                            && (value == null || !value.equals(defaultValue))) {
                        String valueStr = value == null ? NULL_ENCODING : value
                                .toString();
                        properties.put(name, valueStr);
                    }
                }
            }
        } catch (IntrospectionException e) {
            throw new XMLUIException(e);
        } catch (IllegalArgumentException e) {
            throw new XMLUIException(e);
        } catch (IllegalAccessException e) {
            throw new XMLUIException(e);
        } catch (InvocationTargetException e) {
            throw new XMLUIException(e);
        }

        recodeSizeProperties(properties, component);

        addMargins(component, copy, properties);

        return properties;
    }

    private void addMargins(Component component, Component copy,
            Map<String, String> properties) {
        if (component instanceof MarginHandler) {
            MarginInfo m = ((MarginHandler) component).getMargin();
            MarginInfo def = ((MarginHandler) copy).getMargin();
            if (def.getBitMask() != m.getBitMask()) {
                String value = "";
                if (m.getBitMask() == 0) {
                    value = "false";
                } else if (m.hasBottom() && m.hasLeft() && m.hasTop()
                        && m.hasRight()) {
                    value = "true";
                } else {
                    if (m.hasBottom()) {
                        value += (value.isEmpty() ? "" : " ") + "bottom";
                    }
                    if (m.hasTop()) {
                        value += (value.isEmpty() ? "" : " ") + "top";
                    }
                    if (m.hasLeft()) {
                        value += (value.isEmpty() ? "" : " ") + "left";
                    }
                    if (m.hasRight()) {
                        value += (value.isEmpty() ? "" : " ") + "right";
                    }
                }
                properties.put("margin", value);
            }
        }
    }

    private void recodeSizeProperties(Map<String, String> properties,
            Component component) {
        if (component instanceof Sizeable) {
            Sizeable s = component;
            if (properties.containsKey("height")
                    || properties.containsKey("heightUnits")) {
                properties.remove("heightUnits");
                properties.put("height", s.getHeight()
                        + Sizeable.UNIT_SYMBOLS[s.getHeightUnits()]);
            }
            if (properties.containsKey("width")
                    || properties.containsKey("widthUnits")) {
                properties.remove("widthUnits");
                properties.put("width", s.getWidth()
                        + Sizeable.UNIT_SYMBOLS[s.getWidthUnits()]);
            }
        }
    }

    /**
     * Resets the component tree and rebuilds it from XML.
     * 
     * @throws XMLUIException
     *             if the XML is not in a supported format.
     */
    public void readFrom(InputStream in) throws XMLUIException {
        componentIdMap.clear();
        root = null;
        Stack<Component> componentStack = new Stack<Component>();
        Stack<Map<String, String>> childAttributesStack = new Stack<Map<String, String>>();

        XMLStreamReader reader;
        try {
            reader = XMLInputFactory.newInstance().createXMLStreamReader(in);

            while (reader.hasNext()) {
                int eventType = reader.next();
                switch (eventType) {
                case XMLStreamConstants.START_ELEMENT:
                    readStartElement(componentStack, childAttributesStack,
                            reader);
                    break;
                case XMLStreamConstants.END_ELEMENT:
                    readEndElement(componentStack, childAttributesStack, reader);
                    break;
                case XMLStreamConstants.CHARACTERS:
                case XMLStreamConstants.CDATA:
                    readText(componentStack, reader);
                    break;
                }
            }

        } catch (XMLStreamException e) {
            throw new XMLUIException(e);
        } catch (FactoryConfigurationError e) {
            throw new XMLUIException(e);
        }
    }

    private String readText(Stack<Component> componentStack,
            XMLStreamReader reader) {
        Component c = componentStack.peek();
        if (c instanceof Property) {
            Property p = (Property) c;
            Class type = p.getType();
            if (isTypeSupported(type)) {
                String value = reader.getText();
                if (value != null && !value.isEmpty()) {
                    if (String.class == type && value != null
                            && p.getValue() != null) {
                        p.setValue(p.toString() + decodeValue(value, type));
                    } else {
                        p.setValue(decodeValue(value, type));
                    }
                }
            }
        }
        return null;
    }

    private void readStartElement(Stack<Component> componentStack,
            Stack<Map<String, String>> childAttributesStack,
            XMLStreamReader reader) throws XMLUIException {
        String className = readComponentClassName(reader);
        if ("com.vaadin.ui.c".equals(className)) {
            childAttributesStack.push(readCurrentElementAttributes(reader));
        } else {
            readAndCreateComponent(componentStack, className, reader);
        }
    }

    private String readComponentClassName(XMLStreamReader reader) {
        QName name = reader.getName();
        String nameSpace = name.getNamespaceURI();
        if (nameSpace == null || nameSpace.isEmpty()) {
            nameSpace = DEFAULT_NAMESPACE;
        }
        String className = nameSpace + "." + name.getLocalPart();
        return className;
    }

    private void readAndCreateComponent(Stack<Component> componentStack,
            String className, XMLStreamReader reader) throws XMLUIException {
        Component c = instantiateComponent(className);
        Map<String, String> attrs = readCurrentElementAttributes(reader);
        if (attrs.containsKey("id")) {
            setId(c, attrs.get("id"));
            attrs.remove("id");
        }
        try {
            BeanInfo bi = Introspector.getBeanInfo(c.getClass());
            PropertyDescriptor pds[] = bi.getPropertyDescriptors();
            for (int i = 0; i < pds.length; i++) {
                String name = pds[i].getName();
                if (c instanceof Sizeable
                        && ("height".equals(name) || "width".equals(name))) {
                    continue;
                }
                if (attrs.containsKey(name)) {
                    String value = attrs.get(name);
                    Method writeMethod = pds[i].getWriteMethod();
                    Class type = pds[i].getPropertyType();
                    if (isTypeSupported(type)) {
                        writeMethod.invoke(c, decodeValue(value, type));
                    }

                }
            }
        } catch (IntrospectionException e) {
            throw new XMLUIException(e);
        } catch (IllegalArgumentException e) {
            throw new XMLUIException(e);
        } catch (IllegalAccessException e) {
            throw new XMLUIException(e);
        } catch (InvocationTargetException e) {
            throw new XMLUIException(e);
        }

        readSize(c, attrs);
        readMargin(c, attrs.get("margin"));

        componentStack.push(c);
        if (root == null) {
            root = c;
        }
    }

    private void readMargin(Component c, String value) {
        if (!(c instanceof MarginHandler)) {
            return;
        }
        if (value == null) {
            return;
        }
        MarginHandler mh = (MarginHandler) c;
        if ("true".equals(value)) {
            mh.setMargin(new MarginInfo(true));
        } else if ("false".equals(value)) {
            mh.setMargin(new MarginInfo(false));
        } else {
            mh.setMargin(new MarginInfo(value.indexOf("top") >= 0, value
                    .indexOf("right") >= 0, value.indexOf("bottom") >= 0, value
                    .indexOf("left") >= 0));
        }
    }

    private void readSize(Component c, Map<String, String> attrs) {
        if (c instanceof Sizeable) {
            if (attrs.containsKey("height")) {
                ((Sizeable) c).setHeight(attrs.get("height"));
            }
            if (attrs.containsKey("width")) {
                ((Sizeable) c).setWidth(attrs.get("width"));
            }
        }
    }

    private Object decodeValue(String value, Class type) {
        if (type == String.class) {
            return NULL_ENCODING.equals(value) ? null : value;
        } else if (type == java.lang.Boolean.TYPE) {
            return Boolean.parseBoolean(value);
        } else if (type == java.lang.Integer.TYPE) {
            return Integer.parseInt(value);
        } else if (type == java.lang.Long.TYPE) {
            return Long.parseLong(value);
        } else if (type == java.lang.Float.TYPE) {
            return Float.parseFloat(value);
        } else if (type == java.lang.Double.TYPE) {
            return Double.parseDouble(value);
        } else if (type == java.lang.Character.TYPE) {
            return value.charAt(0);
        } else if (type == java.lang.Byte.TYPE) {
            return Byte.parseByte(value);
        }
        throw new UnsupportedOperationException(type.getName()
                + " is not supported.");
    }

    private boolean isTypeSupported(Class type) {
        return type == String.class || type.isPrimitive();
    }

    private Component instantiateComponent(String className)
            throws XMLUIException {
        try {
            Class cc = Panel.class.getClassLoader().loadClass(className);
            return (Component) cc.newInstance();
        } catch (ClassNotFoundException e) {
            throw new XMLUIException(e);
        } catch (InstantiationException e) {
            throw new XMLUIException(e);
        } catch (IllegalAccessException e) {
            throw new XMLUIException(e);
        }
    }

    private Map<String, String> readCurrentElementAttributes(
            XMLStreamReader reader) {
        Map<String, String> am = new HashMap<String, String>();
        for (int i = reader.getAttributeCount(); i > 0; i--) {
            String n = reader.getAttributeLocalName(i - 1);
            String v = reader.getAttributeValue(i - 1);
            am.put(n, v);
        }
        return am;
    }

    private void readEndElement(Stack<Component> componentStack,
            Stack<Map<String, String>> childAttributesStack,
            XMLStreamReader reader) {
        String cn = readComponentClassName(reader);
        if (!"com.vaadin.ui.c".equals(cn)) {
            Component c = componentStack.pop();
            if (!c.getClass().getName().equals(cn)) {
                throw new IllegalStateException();
            }
            if (!componentStack.isEmpty()) {
                ComponentContainer parent = (ComponentContainer) componentStack
                        .peek();
                if (parent instanceof Window && c instanceof Window) {
                    ((Window) parent).addWindow((Window) c);
                } else if (parent instanceof Panel) {
                    ((Panel) parent).setContent((ComponentContainer) c);
                } else if (parent instanceof Form) {
                    ((Form) parent).setLayout((Layout) c);
                } else {
                    Map<String, String> attrs = childAttributesStack.pop();
                    addComponentWithAttributes(parent, c, attrs);
                }
            }
        }
    }

    private void addComponentWithAttributes(ComponentContainer parent,
            Component child, Map<String, String> attrs) {
        if (parent instanceof TabSheet) {
            TabSheet.Tab tab = ((TabSheet) parent).addTab(child);
            if (attrs.containsKey("caption")) {
                tab.setCaption(attrs.get("caption"));
            }
            if (attrs.containsKey("description")) {
                tab.setCaption(attrs.get("description"));
            }
        } else if (parent instanceof AbstractOrderedLayout) {
            parent.addComponent(child);
            if (attrs.containsKey("expandRatio")) {
                ((AbstractOrderedLayout) parent).setExpandRatio(child, Float
                        .parseFloat(attrs.get("expandRatio")));
            }
        } else if (parent instanceof GridLayout) {
            // TODO Implement GridLayout support
            throw new UnsupportedOperationException("GridLayout");
        } else if (parent instanceof AbsoluteLayout) {
            // TODO Implement AbsoluteLayout support
            throw new UnsupportedOperationException("AbsoluteLayout");
        } else if (parent instanceof Accordion) {
            // TODO Implement Accordionsupport
            throw new UnsupportedOperationException("Accordion");
        } else if (parent instanceof CssLayout) {
            // TODO Implement CssLayout support
            throw new UnsupportedOperationException("CssLayout");
        } else {
            parent.addComponent(child);
        }

        if (parent instanceof AlignmentHandler
                && attrs.containsKey("alignment")) {
            AlignmentUtils.setComponentAlignment((AlignmentHandler) parent,
                    child, attrs.get("alignment"));
        }

    }
}
